/**
 * Copyright (c) 2013-2020 Contributors to the Eclipse Foundation
 *
 * <p> See the NOTICE file distributed with this work for additional information regarding copyright
 * ownership. All rights reserved. This program and the accompanying materials are made available
 * under the terms of the Apache License, Version 2.0 which accompanies this distribution and is
 * available at http://www.apache.org/licenses/LICENSE-2.0.txt
 */
package org.locationtech.geowave.analytic;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.UUID;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.math3.geometry.euclidean.twod.Vector2D;
import org.geotools.feature.simple.SimpleFeatureBuilder;
import org.geotools.feature.simple.SimpleFeatureTypeBuilder;
/** Generate clusters of geometries. */
import org.geotools.referencing.CRS;
import org.locationtech.geowave.adapter.vector.FeatureDataAdapter;
import org.locationtech.geowave.analytic.distance.CoordinateCircleDistanceFn;
import org.locationtech.geowave.analytic.distance.DistanceFn;
import org.locationtech.geowave.core.geotime.index.SpatialDimensionalityTypeProvider;
import org.locationtech.geowave.core.geotime.index.SpatialOptions;
import org.locationtech.geowave.core.store.api.DataStore;
import org.locationtech.geowave.core.store.api.Index;
import org.locationtech.geowave.core.store.api.Writer;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.type.AttributeDescriptor;
import org.opengis.feature.type.GeometryType;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.cs.CoordinateSystemAxis;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Generate clusters of geometries. */
public class GeometryDataSetGenerator {
  static final Logger LOGGER = LoggerFactory.getLogger(GeometryDataSetGenerator.class);
  private final Random rand = new Random();
  private final GeometryFactory geoFactory = new GeometryFactory();
  private final DistanceFn<SimpleFeature> distanceFunction;
  private final SimpleFeatureBuilder builder;
  // coordinate system boundaries
  private SimpleFeature minFeature;
  private double[] minAxis;
  private double[] maxAxis;
  private CoordinateSystem coordSystem;
  private boolean includePolygons = true;

  public GeometryDataSetGenerator(
      final DistanceFn<SimpleFeature> distanceFunction,
      final SimpleFeatureBuilder builder) {
    super();
    this.distanceFunction = distanceFunction;
    this.builder = builder;
    init();
  }

  public boolean isIncludePolygons() {
    return includePolygons;
  }

  public void setIncludePolygons(final boolean includePolygons) {
    this.includePolygons = includePolygons;
  }

  public SimpleFeature getCorner() {
    return minFeature;
  }

  public Geometry getBoundingRegion() {
    final int[] adder = {1, 2, -1, 2};
    int num = 0;
    int addCnt = 0;
    final int dims = coordSystem.getDimension();
    final int coords = (int) Math.pow(dims, 2);

    final Coordinate[] coordinates = new Coordinate[coords + 1];
    for (int i = 0; i < coords; i++) {
      coordinates[i] = new Coordinate();
      for (int j = 0; j < dims; j++) {
        final boolean isMin = ((num >> j) % 2) == 0;
        coordinates[i].setOrdinate(j, isMin ? minAxis[j] : maxAxis[j]);
      }
      num += adder[addCnt];
      addCnt = (addCnt + 1) % 4;
    }
    coordinates[coords] = coordinates[0];
    return geoFactory.createPolygon(coordinates);
  }

  /**
   * Calculate the range for the given bounds
   *
   * @param factor
   * @param minAxis
   * @param maxAxis
   * @return
   */
  private double[] createRange(
      final double factor,
      final double[] minAxis,
      final double[] maxAxis) {
    final double[] range = new double[minAxis.length];
    for (int i = 0; i < minAxis.length; i++) {
      range[i] = (maxAxis[i] - minAxis[i]) * factor;
    }
    return range;
  }

  /**
   * Pick a random grid cell and supply the boundary. The grid is determined by the parameter,which
   * provides a percentage of distance over the total range for each cell.
   *
   * @param minCenterDistanceFactor
   * @return
   */
  private Pair<double[], double[]> gridCellBounds(
      final double minCenterDistanceFactor,
      final double[] minAxis,
      final double[] maxAxis) {
    final double[] range = createRange(1.0, minAxis, maxAxis);
    final double[] min = new double[range.length];
    final double[] max = new double[range.length];
    for (int i = 0; i < range.length; i++) {
      // HP Fortify "Insecure Randomness" false positive
      // This random number is not used for any purpose
      // related to security or cryptography
      min[i] =
          Math.max(
              minAxis[i]
                  + (minCenterDistanceFactor
                      * (rand.nextInt(Integer.MAX_VALUE) % (range[i] / minCenterDistanceFactor))),
              minAxis[i]);
      max[i] = Math.min(min[i] + (minCenterDistanceFactor * range[i]), maxAxis[i]);
    }
    return Pair.of(min, max);
  }

  public void writeToGeoWave(final DataStore dataStore, final List<SimpleFeature> featureData)
      throws IOException {
    final Index index =
        SpatialDimensionalityTypeProvider.createIndexFromOptions(new SpatialOptions());
    final FeatureDataAdapter adapter = new FeatureDataAdapter(featureData.get(0).getFeatureType());
    final SimpleFeatureBuilder featureBuilder =
        new SimpleFeatureBuilder(featureData.get(0).getFeatureType());

    LOGGER.info(
        "Writing " + featureData.size() + " records to " + adapter.getFeatureType().getTypeName());
    dataStore.addType(adapter, index);
    try (Writer writer = dataStore.createWriter(adapter.getTypeName())) {
      for (final SimpleFeature feature : featureData) {
        writer.write(feature);
        featureBuilder.reset();
      }
    }
  }

  public List<SimpleFeature> generatePointSet(
      final double minCenterDistanceFactor,
      final double outlierFactor,
      final int numberOfCenters,
      final int minSetSize) {
    return this.generatePointSet(
        minCenterDistanceFactor,
        outlierFactor,
        numberOfCenters,
        minSetSize,
        minAxis,
        maxAxis);
  }

  public List<SimpleFeature> generatePointSet(
      final LineString line,
      final double distanceFactor,
      final int points) {
    final List<SimpleFeature> pointSet = new ArrayList<>();
    for (final Point point : CurvedDensityDataGeneratorTool.generatePoints(
        line,
        distanceFactor,
        points)) {
      pointSet.add(createFeatureWithGeometry(point));
    }
    return pointSet;
  }

  public List<SimpleFeature> generatePointSet(
      final double minCenterDistanceFactor,
      final double outlierFactor,
      final int numberOfCenters,
      final int minSetSize,
      final double[] minAxis,
      final double[] maxAxis) {

    final List<SimpleFeature> pointSet = new ArrayList<>();
    final List<double[]> minForCenter = new ArrayList<>();
    final List<double[]> maxForCenter = new ArrayList<>();
    final double[] range = createRange(minCenterDistanceFactor, minAxis, maxAxis);
    if (numberOfCenters >= minSetSize) {
      LOGGER.error("The number of centers passed much be less than the minimum set size");
      throw new IllegalArgumentException(
          "The number of centers passed much be less than the minimum set size");
    }

    final double minDistance = computeMinDistance(minCenterDistanceFactor, minAxis, maxAxis);

    /** Pick the initial centers which have minimum distance from each other. */
    while (pointSet.size() < numberOfCenters) {

      final Pair<double[], double[]> axis =
          gridCellBounds(minCenterDistanceFactor, minAxis, maxAxis);

      final SimpleFeature nextFeature = createNewFeature(axis.getLeft(), axis.getRight());
      if (isFarEnough(nextFeature, pointSet, minDistance)) {
        pointSet.add(nextFeature);
      }
    }

    /**
     * Calculate the boundaries around each center point to place additional points, thus creating
     * clusters
     */
    for (final SimpleFeature center : pointSet) {
      final double[] centerMinAxis = new double[coordSystem.getDimension()];
      final double[] centerMaxAxis = new double[coordSystem.getDimension()];
      final Geometry geo = (Geometry) center.getDefaultGeometry();
      final Coordinate centerCoord = geo.getCentroid().getCoordinate();
      for (int i = 0; i < centerMinAxis.length; i++) {
        centerMinAxis[i] = centerCoord.getOrdinate(i) - (range[i] / 2.0);
        centerMaxAxis[i] = centerCoord.getOrdinate(i) + (range[i] / 2.0);
      }
      minForCenter.add(centerMinAxis);
      maxForCenter.add(centerMaxAxis);
    }

    /*
     * Pick a random center point and add a new geometry with the bounding range around that point.
     */
    final int clusterdItemsCount = (int) Math.ceil((minSetSize) * (1.0 - outlierFactor));
    while (pointSet.size() < clusterdItemsCount) {
      // HP Fortify "Insecure Randomness" false positive
      // This random number is not used for any purpose
      // related to security or cryptography
      final int centerPos = rand.nextInt(Integer.MAX_VALUE) % minForCenter.size();

      pointSet.add(createNewFeature(minForCenter.get(centerPos), maxForCenter.get(centerPos)));
    }

    /** Add random points as potential outliers (no guarantees) */
    while (pointSet.size() < minSetSize) {
      pointSet.add(createNewFeature(minAxis, maxAxis));
    }
    return pointSet;
  }

  public List<SimpleFeature> addRandomNoisePoints(
      final List<SimpleFeature> pointSet,
      final int minSetSize,
      final double[] minAxis,
      final double[] maxAxis) {
    while (pointSet.size() < minSetSize) {
      pointSet.add(createNewFeature(minAxis, maxAxis));
    }
    return pointSet;
  }

  private void init() {
    coordSystem = builder.getFeatureType().getCoordinateReferenceSystem().getCoordinateSystem();

    minAxis = new double[coordSystem.getDimension()];
    maxAxis = new double[coordSystem.getDimension()];
    for (int i = 0; i < coordSystem.getDimension(); i++) {
      final CoordinateSystemAxis axis = coordSystem.getAxis(i);
      minAxis[i] = axis.getMinimumValue();
      maxAxis[i] = axis.getMaximumValue();
    }
    final int dims = coordSystem.getDimension();

    final Coordinate coordinate = new Coordinate();
    for (int i = 0; i < dims; i++) {
      coordinate.setOrdinate(i, minAxis[i]);
    }
    minFeature = createFeatureWithGeometry(geoFactory.createPoint(coordinate));
  }

  private boolean isFarEnough(
      final SimpleFeature feature,
      final List<SimpleFeature> set,
      final double minDistance) {
    for (final SimpleFeature setItem : set) {
      if (distanceFunction.measure(feature, setItem) < minDistance) {
        return false;
      }
    }
    return true;
  }

  /**
   * Find the distance maximum distance of the entire space and multiply that by the distance factor
   * to determine a minimum distance each initial center point occurs from each other.
   *
   * @param minCenterDistanceFactor
   * @return
   */
  private double computeMinDistance(
      final double minCenterDistanceFactor,
      final double[] minAxis,
      final double[] maxAxis) {
    assert minCenterDistanceFactor < 0.75;

    final int dims = coordSystem.getDimension();

    Coordinate coordinate = new Coordinate();
    for (int i = 0; i < dims; i++) {
      coordinate.setOrdinate(i, minAxis[i]);
    }
    final SimpleFeature minFeature = createFeatureWithGeometry(geoFactory.createPoint(coordinate));

    coordinate = new Coordinate();
    for (int i = 0; i < dims; i++) {
      coordinate.setOrdinate(i, maxAxis[i]);
    }

    final SimpleFeature maxFeature = createFeatureWithGeometry(geoFactory.createPoint(coordinate));

    return minCenterDistanceFactor * distanceFunction.measure(minFeature, maxFeature);
  }

  private SimpleFeature createNewFeature(final double[] minAxis, final double[] maxAxis) {

    final int dims = coordSystem.getDimension();

    // HP Fortify "Insecure Randomness" false positive
    // This random number is not used for any purpose
    // related to security or cryptography
    final int shapeSize = includePolygons ? (rand.nextInt(Integer.MAX_VALUE) % 5) + 1 : 1;
    final Coordinate[] shape = new Coordinate[shapeSize > 2 ? shapeSize + 1 : shapeSize];
    final double[] constrainedMaxAxis = Arrays.copyOf(maxAxis, maxAxis.length);
    final double[] constrainedMinAxis = Arrays.copyOf(minAxis, minAxis.length);
    for (int s = 0; s < shapeSize; s++) {
      final Coordinate coordinate = new Coordinate();
      for (int i = 0; i < dims; i++) {
        // HP Fortify "Insecure Randomness" false positive
        // This random number is not used for any purpose
        // related to security or cryptography
        coordinate.setOrdinate(
            i,
            constrainedMinAxis[i]
                + (rand.nextDouble() * (constrainedMaxAxis[i] - constrainedMinAxis[i])));
      }
      shape[s] = coordinate;
      if (s == 0) {
        constrain(coordinate, constrainedMaxAxis, constrainedMinAxis);
      }
    }
    if (shapeSize > 2) {
      shape[shapeSize] = shape[0];
      return createFeatureWithGeometry(geoFactory.createLinearRing(shape).convexHull());
    } else if (shapeSize == 2) {
      return createFeatureWithGeometry(geoFactory.createLineString(shape));
    } else {
      return createFeatureWithGeometry(geoFactory.createPoint(shape[0]));
    }
  }

  public GeometryFactory getFactory() {
    return geoFactory;
  }

  /**
   * Change the constrain min and max to center around the coordinate to keep the polygons tight.
   *
   * @param coordinate
   * @param constrainedMaxAxis
   * @param constrainedMinAxis
   */
  private void constrain(
      final Coordinate coordinate,
      final double[] constrainedMaxAxis,
      final double[] constrainedMinAxis) {
    for (int i = 0; i < constrainedMaxAxis.length; i++) {
      final double range = (constrainedMaxAxis[i] - constrainedMinAxis[i]) * 0.001;
      constrainedMaxAxis[i] = Math.min(coordinate.getOrdinate(i) + range, constrainedMaxAxis[i]);
      constrainedMinAxis[i] = Math.max(coordinate.getOrdinate(i) - range, constrainedMinAxis[i]);
    }
  }

  private SimpleFeature createFeatureWithGeometry(final Geometry geometry) {
    final Object[] values = new Object[builder.getFeatureType().getAttributeCount()];
    for (int i = 0; i < values.length; i++) {
      final AttributeDescriptor desc = builder.getFeatureType().getDescriptor(i);
      if (desc.getType() instanceof GeometryType) {
        values[i] = geometry;
      } else {
        final Class<?> binding = desc.getType().getBinding();
        if (String.class.isAssignableFrom(binding)) {
          values[i] = UUID.randomUUID().toString();
        }
      }
    }
    return builder.buildFeature(UUID.randomUUID().toString(), values);
  }

  // public static void main(
  // final String[] args )
  // throws Exception {
  // final Options allOptions = new Options();
  // DataStoreCommandLineOptions.applyOptions(allOptions);
  // final Option typeNameOption = new Option(
  // "typename",
  // true,
  // "a name for the feature type (required)");
  // typeNameOption.setRequired(true);
  // allOptions.addOption(typeNameOption);
  // CommandLine commandLine = new BasicParser().parse(
  // allOptions,
  // args);
  //
  // final CommandLineResult<DataStoreCommandLineOptions> dataStoreOption =
  // DataStoreCommandLineOptions.parseOptions(
  // allOptions,
  // commandLine);
  // if (dataStoreOption.isCommandLineChange()) {
  // commandLine = dataStoreOption.getCommandLine();
  // }
  // else {
  // throw new ParseException(
  // "Unable to parse data store from command line");
  // }
  // final DataStore dataStore = dataStoreOption.getResult().createStore();
  // final String typeName = commandLine.getOptionValue("typename");
  // final GeometryDataSetGenerator dataGenerator = new
  // GeometryDataSetGenerator(
  // new FeatureCentroidDistanceFn(),
  // getBuilder(typeName));
  // dataGenerator.writeToGeoWave(
  // dataStore,
  // dataGenerator.generatePointSet(
  // 0.2,
  // 0.2,
  // 5,
  // 5000,
  // new double[] {
  // -100,
  // -45
  // },
  // new double[] {
  // -90,
  // -35
  // }));
  // dataGenerator.writeToGeoWave(
  // dataStore,
  // dataGenerator.generatePointSet(
  // 0.2,
  // 0.2,
  // 7,
  // 5000,
  // new double[] {
  // 0,
  // 0
  // },
  // new double[] {
  // 10,
  // 10
  // }));
  // dataGenerator.writeToGeoWave(
  // dataStore,
  // dataGenerator.addRandomNoisePoints(
  // dataGenerator.generatePointSet(
  // 0.2,
  // 0.2,
  // 6,
  // 5000,
  // new double[] {
  // 65,
  // 35
  // },
  // new double[] {
  // 75,
  // 45
  // }),
  // 6000,
  // new double[] {
  // -90,
  // -90
  // },
  // new double[] {
  // 90,
  // 90
  // }));
  // }

  private static SimpleFeatureBuilder getBuilder(final String name) throws FactoryException {
    final SimpleFeatureTypeBuilder typeBuilder = new SimpleFeatureTypeBuilder();
    typeBuilder.setName(name);
    typeBuilder.setCRS(CRS.decode("EPSG:4326", true)); // <- Coordinate
    // reference
    // add attributes in order
    typeBuilder.add("geom", Geometry.class);
    typeBuilder.add("name", String.class);
    typeBuilder.add("count", Long.class);

    // build the type
    return new SimpleFeatureBuilder(typeBuilder.buildFeatureType());
  }

  public static class CurvedDensityDataGeneratorTool {

    private static final CoordinateCircleDistanceFn DISTANCE_FN = new CoordinateCircleDistanceFn();

    private CurvedDensityDataGeneratorTool() {}

    public static final List<Point> generatePoints(
        final LineString line,
        final double distanceFactor,
        final int points) {
      final List<Point> results = new ArrayList<>();
      Coordinate lastCoor = null;
      double distanceTotal = 0.0;
      final double[] distancesBetweenCoords = new double[line.getCoordinates().length - 1];
      int i = 0;
      for (final Coordinate coor : line.getCoordinates()) {
        if (lastCoor != null) {
          distancesBetweenCoords[i] = Math.abs(DISTANCE_FN.measure(lastCoor, coor));
          distanceTotal += distancesBetweenCoords[i++];
        }
        lastCoor = coor;
      }
      lastCoor = null;
      i = 0;
      for (final Coordinate coor : line.getCoordinates()) {
        if (lastCoor != null) {
          results.addAll(
              generatePoints(
                  line.getFactory(),
                  toVec(coor),
                  toVec(lastCoor),
                  distanceFactor,
                  (int) ((points) * (distancesBetweenCoords[i++] / distanceTotal))));
        }
        lastCoor = coor;
      }

      return results;
    }

    private static final List<Point> generatePoints(
        final GeometryFactory factory,
        final Vector2D coordinateOne,
        final Vector2D coordinateTwo,
        final double distanceFactor,
        final int points) {
      final List<Point> results = new ArrayList<>();
      final Random rand = new Random();
      final Vector2D originVec = coordinateTwo.subtract(coordinateOne);
      for (int i = 0; i < points; i++) {
        // HP Fortify "Insecure Randomness" false positive
        // This random number is not used for any purpose
        // related to security or cryptography
        final double factor = rand.nextDouble();
        final Vector2D projectionPoint = originVec.scalarMultiply(factor);
        final double direction = rand.nextGaussian() * distanceFactor;
        final Vector2D orthogonal = new Vector2D(originVec.getY(), -originVec.getX());

        results.add(
            factory.createPoint(
                toCoordinate(
                    orthogonal.scalarMultiply(direction).add(projectionPoint).add(coordinateOne))));
      }
      return results;
    }

    public static Coordinate toCoordinate(final Vector2D vec) {
      return new Coordinate(vec.getX(), vec.getY());
    }

    public static Vector2D toVec(final Coordinate coor) {
      return new Vector2D(coor.x, coor.y);
    }
  }
}
